Перейти к основному содержимому

5.16. История языка Си

Разработчику Архитектору

Си

История языка Си

Язык программирования Си занимает особое положение в истории информатики: он не был первым, не был самым безопасным и не стремился к максимальной абстракции, однако именно его архитектурные решения, баланс между низкоуровневым контролем и переносимостью, а также своевременное появление в критической инфраструктурной среде определили его исключительную роль как лингвистического фундамента современных вычислительных систем. Понимание истории Си требует рассмотрения не только его синтаксических и семантических черт, но и контекста — технологического, институционального и методологического, в котором он возник.

1. Предпосылки: от теоретических основ к практическим ограничениям

К началу 1970-х годов ландшафт языков программирования был уже достаточно разнообразен. Наиболее влиятельными направлениями были:

  • Фортран (1957) — ориентированный на научные вычисления, с мощной оптимизацией арифметических выражений, но крайне ограниченный в области системного программирования; отсутствие прямого доступа к адресному пространству и аппаратным ресурсам делало его непригодным для разработки операционных систем.
  • Кобол (1959) — язык для бизнес-приложений, ориентированный на читаемость, но не на эффективность выполнения или контроль над памятью.
  • Алгол-60 (1960) — теоретически строгий, с чётко формализованной семантикой, оказал громадное влияние на последующую генерацию языков, но оставался академическим инструментом; его реализации были громоздкими, а отладка — затруднённой.
  • Лисп (1958) — язык символических вычислений, основанный на рекурсии и динамической типизации, принципиально далёкий от парадигмы прямого управления памятью.

Ни один из этих языков не удовлетворял потребностям системного программирования, понимаемого как создание программного обеспечения, тесно взаимодействующего с аппаратными архитектурами: загрузчиков, ядер ОС, драйверов устройств, компиляторов. Такие задачи требовали:

  • минимальных накладных расходов времени выполнения;
  • детерминированного отображения исходного кода в машинные инструкции;
  • прямой адресации памяти;
  • лёгкости реализации компилятора на ограниченных по ресурсам машинах.

В условиях, когда типичная ЭВМ того времени (например, PDP-7 или PDP-11) обладала объёмом ОЗУ в десятки килобайт, а время разработки и отладки ОС измерялось месяцами, необходимость в практичном, компактном и переносимом системном языке становилась критической.

2. Путь к Си: BCPL → B → Си

Рождение Си не было внезапным актом творчества — оно стало результатом итеративного уточнения концепций, начатых в рамках проекта MULTICS и продолженных в Bell Labs.

BCPL (Basic Combined Programming Language), созданный Мартином Ричардсом в Кембриджском университете (1966–1967), ввёл ключевую идею: язык не обязан быть сложным для того, чтобы быть мощным. BCPL был разработан как метаязык — язык для написания компиляторов и системного ПО. Его отличали:

  • тип word как единственная машинно-зависимая сущность (аналог 32-битного слова);
  • отсутствие строгой типизации — программист сам управлял интерпретацией битовых последовательностей;
  • семантика, основанная на регистровых машинах с непрерывной памятью;
  • компактный и легко реализуемый компилятор (около 1000 строк на ассемблере).

BCPL оказал значительное влияние на Кена Томпсона, работавшего в Bell Labs над MULTICS, а затем — над его альтернативой, проектом, который получит название UNIX. На PDP-7, где ресурсы были крайне ограничены, Томпсон адаптировал BCPL, упростив его синтаксис и семантику. Так возник язык B (1969–1970).

B сохранил тип word как единственный тип данных — и тем самым унаследовал главный недостаток BCPL: невозможность эффективной работы с типами разного размера (например, char, short, int). Поскольку PDP-7 обрабатывала слова по 18 бит, а адресация осуществлялась в единицах слов, а не байтов, B не имел встроенного представления для однобайтовых значений. Это не позволяло эффективно обрабатывать текст — одну из ключевых задач UNIX (например, реализация утилит cat, grep, ed). Кроме того, отсутствие статической типизации затрудняло оптимизацию компилятора: каждая арифметическая операция требовала проверки размера операнда во время выполнения.

Эти ограничения стали катализатором следующего шага.

3. Появление Си: 1971–1973

Деннис Ритчи, коллега Томпсона в Bell Labs, начал работу над преемником B в 1971 году. Первоначально проект назывался «NB» (New B), однако уже к 1972 году он эволюционировал в самостоятельный язык — Си.

Переломным моментом стало введение статической типизации и разноразмерных типов данных. В отличие от B, где всё было word, в Си появились:

  • char — единица адресуемой памяти (обычно 8 бит);
  • int — машинное целое (размер, соответствующий архитектуре — 16 или 32 бита);
  • float и double — вещественные типы (поддержка аппаратного FPU на PDP-11);
  • производные типы: массивы, структуры, указатели.

Важнейшим концептуальным решением стало то, что указатель в Си не является отдельным типом, а производится от типа объекта, на который он указывает: int*, char* и т.д. Это позволило:

  • сохранить низкоуровневый контроль (арифметика указателей работает в единицах размера типа);
  • обеспечить частичную типобезопасность (компилятор может проверить, согласованы ли типы при присваивании указателей);
  • упростить реализацию абстракций, таких как массивы и строки (строка — просто char*, завершённая нулевым байтом \0).

Одновременно был переработан синтаксис выражений. В B условные и циклические конструкции окружались ключевыми словами if (...) ... else ..., но без фигурных скобок; блоки оформлялись через begin ... end. Ритчи перенял фигурные скобки из языка Алгол-68 — несмотря на критику со стороны некоторых коллег (в том числе Брайана Кернигана, который первоначально возражал против «лишних символов»), это решение оказалось чрезвычайно удачным: скобки упростили парсинг, увеличили локальность видимости переменных и позволили строить вложенные структуры без двусмысленности.

К 1973 году Си достиг достаточной зрелости для того, чтобы стать основным языком реализации UNIX. Переписывание ядра с ассемблера PDP-11 на Си — событие, имевшее стратегическое значение. Оно продемонстрировало, что язык способен:

  • генерировать код, сравнимый по эффективности с «ручным» ассемблером;
  • обеспечивать переносимость: ядро UNIX, написанное на Си, могло быть скомпилировано на других архитектурах с минимальными изменениями (в основном — в части машинно-зависимого кода, изолированного в отдельных модулях);
  • ускорять разработку: объём исходного кода сократился, читаемость возросла, отладка стала проще.

Этот успех не был чисто техническим — он был социотехническим. Bell Labs, будучи исследовательским подразделением AT&T, не имел коммерческих ограничений на распространение UNIX (до антимонопольного разбирательства 1956 года AT&T была запрещена конкурировать в коммерческом ПО). UNIX вместе с исходным кодом распространялся по университетам — и вместе с ним — компилятор Си и документация. Так формировалось первое поколение инженеров, для которых Си и UNIX стали естественной средой.


4. K&R C: от внутренней спецификации к общепринятому эталону

К 1977 году Си уже активно использовался как внутри Bell Labs, так и в академической среде, однако его описание существовало лишь в форме внутренних документов и реализации компилятора. Такое положение порождало серьёзные риски: различия в поведении между компиляторами (например, на PDP-11 и Interdata 8/32), неоднозначность семантики неопределённых конструкций, отсутствие гарантий переносимости. Необходимость консолидировать знание о языке стала очевидной.

Решающую роль сыграла книга «The C Programming Language», написанная Брайаном Керниганом и Деннисом Ритчи и впервые опубликованная в 1978 году. Несмотря на скромный объём (всего 228 страниц в первом издании), она мгновенно получила статус канонического источника. Причины этого успеха лежат не только в авторитете Ритчи как создателя языка, но и в методологической продуманности изложения:

  • Книга построена не как справочник, а как обучающий трактат, где каждая глава вводит новую концепцию через практические примеры: от «Hello, World» до реализации простого калькулятора по принципу «лексер → парсер → интерпретатор»;
  • Стиль кода — лаконичный, без избыточных комментариев, но с акцентом на идиомы, которые позже станут стандартом (например, for (i = 0; s[i] != '\0'; i++), while (*s++ = *t++));
  • Явно выделены машинно-зависимые аспекты (например, размеры типов, порядок байтов, представление знаковых чисел) и даны рекомендации по их изоляции;
  • В приложении приведена почти полная грамматика языка в форме Бэкуса—Наура (BNF), что сделало её основой для первых независимых реализаций компиляторов.

Важно подчеркнуть: K&R не устанавливали стандарт в юридическом смысле — они фиксировали состояние языка на момент 1978 года, которое стало известно как K&R C. Это была не формальная спецификация, а описание реализации, и в нём оставались неопределённые аспекты (например, порядок вычисления аргументов функции, поведение при переполнении знакового целого). Тем не менее, именно K&R C формировало ожидания разработчиков почти десятилетие и служило ориентиром для всех компиляторов того времени — от первых портов на VAX и Motorola 68000 до портативных реализаций вроде Lattice C и Microsoft C.

Этот этап демонстрирует важный принцип в эволюции языков: практическое внедрение и документирование часто опережают формальную стандартизацию. В случае с Си именно практико-ориентированное описание позволило языку распространиться быстрее, чем это было бы возможно при ожидании формального комитета.

5. ANSI C (C89/C90): формализация без потери сути

К середине 1980-х годов фрагментация реализаций Си стала угрожать его переносимости. Коммерческие компиляторы (например, от Microsoft, Borland, Watcom) вводили собственные расширения: ключевые слова huge, near, far для сегментной архитектуры x86; встроенные функции; расширенные атрибуты типов. В то же время, UNIX-сообщество сталкивалось с несовместимостью между System V и BSD в части системных заголовков и ABI (Application Binary Interface).

В 1983 году Американский национальный институт стандартов (ANSI) учредил комитет X3J11 с целью разработки официального стандарта языка Си. Работа комитета, в который вошли Ритчи, Керниган, Гай Стил, Том Платт и другие видные специалисты, длилась шесть лет и завершилась публикацией стандарта ANSI X3.159-1989, известного как C89. В 1990 году он был принят Международной организацией по стандартизации как ISO/IEC 9899:1990 (C90), с минимальными редакционными правками.

Стандартизация решала три ключевые задачи:

  1. Формализация семантики. Впервые были точно определены:

    • фазы трансляции (лексический анализ, препроцессинг, синтаксический разбор, семантический анализ, генерация кода);
    • правила области видимости и связывания имён (linkage: external, internal, none);
    • поведение при неопределённых и неуточнённых операциях (например, i = i++ + ++i признано undefined behavior, а порядок вычисления подвыражений — unspecified);
    • модель памяти: концепции объекта, адресуемости, выравнивания (alignment), строгого псевдонима (strict aliasing rules — хотя формулировка в C89 была слабее, чем впоследствии в C99).
  2. Введение новых языковых механизмов, не нарушавших обратную совместимость:

    • прототипы функций: синтаксис int printf(const char *format, ...) заменил устаревший K&R-стиль int printf(format, ...) char *format;;
    • ключевое слово void и его использование: void как тип возвращаемого значения, void* как универсальный указатель, void f(void) как функция без параметров (в отличие от void f(), означавшего «произвольное число параметров» в K&R);
    • const и volatile — спецификаторы типа для поддержки оптимизаций и работы с памятью, отображаемой на устройства (memory-mapped I/O);
    • signed как явный антоним unsigned;
    • библиотека времени time.h, локализация через locale.h, функции работы с многобайтовыми символами (заложена основа для поддержки Unicode в будущем).
  3. Определение стандартной библиотеки. Впервые был закреплён набор заголовочных файлов и функций, которые обязаны присутствовать в любой conforming реализации: stdio.h, stdlib.h, string.h, math.h, setjmp.h, signal.h, stdarg.h и др. Это обеспечило реальную переносимость не только ядра языка, но и базовой среды выполнения.

Критически важно, что ANSI C не пытался реинжинирить язык. Комитет сознательно отказался от введения:

  • встроенной поддержки многопоточности (многопоточность тогда реализовалась на уровне ОС, а не языка);
  • исключений (считалось, что они нарушают предсказуемость управления потоком исполнения);
  • автоматического управления памятью (сборка мусора противоречила философии детерминированного освобождения ресурсов);
  • объектно-ориентированных механизмов (наследование, виртуальные функции и т.п.).

Этот консерватизм обеспечил беспрецедентный уровень обратной совместимости: практически весь код, написанный в стиле K&R, компилировался без изменений под ANSI C — за исключением случаев, где использовались неопределённые конструкции, поведение которых стандартизация явно запретила.

Результатом стало укрепление позиций Си как языка системного уровня по умолчанию. ANSI C стал основой для POSIX, для спецификаций компиляторов (например, GCC изначально позиционировался как «ANSI C compiler»), а также для первых кросс-платформенных фреймворков — таких как Motif и позже GTK+.

6. Ранние наследники: как Си породил новые языки

Уже в середине 1980-х стало очевидно, что Си — не конечная точка, а платформа для языковой эволюции. Его успех породил две стратегии расширения:

  • Надстройка над Си без разрыва совместимости: язык остаётся надмножеством Си, сохраняя возможность прямого включения C-кода и совместную линковку.
  • Переосмысление парадигм с сохранением системного контроля: отказ от прямой совместимости, но заимствование ключевых идей — указатели, структуры, модель трансляции «в машинный код без виртуальной машины».

Первой реализованной стратегией стал C++, первоначально называвшийся «C with Classes» (Бьёрн Страуструп, Bell Labs, 1979–1983). Хотя C++ ввёл классы, наследование и шаблоны, его ядро осталось совместимым с Си на уровне синтаксиса и ABI (в пределах одного компилятора). Важно: C++ не является «объектно-ориентированной версией Си» — это отдельный язык, где подмножество, близкое к ANSI C, используется как базовый слой абстракции. Например, оператор new в C++ реализован поверх malloc, а виртуальные таблицы — как структуры данных, управляемые компилятором, но доступные через указатели.

Второй пример — Objective-C (Брэд Кокс и Том Ловеринг, Stepstone, 1983–1986). В отличие от C++, Objective-C остался чистым надмножеством Си: любой C-код является валидным Objective-C-кодом. Динамическая диспетчеризация сообщений ([obj method]) реализована через runtime-библиотеку на Си, а синтаксис классов и методов введён как расширение грамматики, не затрагивающее базовые конструкции. Эта стратегия обеспечила бесшовную интеграцию с UNIX-библиотеками и сделала Objective-C языком по умолчанию для NeXTSTEP, а позже — для macOS и iOS.

Третий путь — использование Си как промежуточного представления. Компиляторы языков вроде Eiffel, Modula-2 и позже Haskell (GHC) генерировали Си-код как промежуточную стадию трансляции, полагаясь на зрелость и переносимость C-компиляторов. Эта практика укрепила статус Си как лингва франка компиляторостроения.


7. C99: модернизация в условиях консерватизма

Появление стандарта ISO/IEC 9899:1999 (C99) стало ответом на накопившиеся за десятилетие после ANSI C запросы со стороны сообщества, а также на эволюцию аппаратных платформ. Однако, в отличие от многих языков, где новые версии радикально переосмысливают парадигму (например, Python 3), C99 придерживался принципа инкрементальной эволюции: каждое нововведение должно было решать конкретную практическую проблему, не нарушая существующих кодовых баз и не увеличивая сложность реализации компиляторов сверх необходимого.

Ключевые направления модернизации были определены на основе опыта портирования UNIX-систем, встраиваемых платформ (особенно 8- и 16-битных контроллеров), а также научных вычислений (где требовалась поддержка комплексных чисел и точного управления выравниванием). Наиболее значимые изменения:

7.1. Синтаксические и семантические уточнения
  • Однострочные комментарии (//) — заимствованные из C++, они не только упростили документирование, но и позволили легко временно отключать фрагменты кода (в том числе — целые строки в макросах), что оказалось особенно полезно при отладке. Хотя это изменение казалось тривиальным, его включение отражало готовность комитета заимствовать проверенные практики из смежных языков при условии обратной совместимости.

  • Переменные в цикле for — возможность объявлять переменную непосредственно в инициализационной части цикла (for (int i = 0; i < n; i++)) локализовала область видимости управляющей переменной, снижая вероятность конфликтов имён в крупных функциях. Это не ввело новую семантику, но привело Си ближе к стилю C++ без нарушения модели области видимости.

  • Гибкие массивы в структурах (struct { int len; double data[]; }) — замена идиомы с «нулевым массивом» (data[0]), которая формально была неопределённым поведением в C89. В C99 массив без указания размера в конце структуры получил строго определённую семантику: он не занимает места в макете структуры, но при распределении памяти через malloc(sizeof(struct) + n * sizeof(double)) корректно участвует в арифметике указателей. Эта конструкция стала основой для эффективной реализации буферов переменного размера (например, в сетевых протоколах типа TLS).

7.2. Типовая система: точность, переносимость, выразительность
  • Стандартные целочисленные типы (<stdint.h>) — введение int8_t, uint32_t, int_fast16_t, int_least64_t и т.д. решило хроническую проблему переносимости по разрядности. В C89 тип int мог быть 16- или 32-битным в зависимости от платформы, что порождало ошибки при интерпретации сетевых пакетов, файловых форматов или регистров устройств. <stdint.h> предоставил гарантированно фиксированные типы, а также типы, оптимизированные под скорость (fast) или минимальный размер (least), не требуя при этом изменения ядра языка.

  • Логический тип (_Bool, заголовок <stdbool.h> с макросом bool) — формализация булевой логики, ранее реализовавшейся через int и макросы (#define TRUE 1). _Bool гарантирует хранение только значений 0 и 1 (даже при присваивании 5 → 1), что упрощает генерацию эффективного кода для условных переходов и позволяет компилятору лучше оптимизировать ветвления.

  • Тип long long — поддержка 64-битных целых на 32-битных архитектурах (актуально с ростом объёмов данных и адресного пространства в 32-битных UNIX-системах). Введён с осторожностью: long long — это не замена long, а дополнительный тип, не нарушение ABI существующих систем.

7.3. Поддержка новых вычислительных моделей
  • Переменные длины массивы (VLA — Variable Length Arrays) — возможность объявлять массивы с размером, определяемым во время выполнения: int n = get_size(); int arr[n];. Это устранило необходимость ручного управления кучей для временных буферов умеренного размера и приблизило Си к языкам с автоматическим управлением стеком (как в Algol-60). Однако VLA не стали обязательной частью в последующих стандартах (см. C11) из-за рисков переполнения стека и сложности статического анализа.

  • Ключевое слово restrict — аннотация для указателей, сигнализирующая компилятору, что никакой другой указатель в данной области видимости не ссылается на ту же область памяти. Это позволило агрессивно оптимизировать циклы (например, избавиться от повторной загрузки из памяти при векторизации), особенно в научных вычислениях. restrict — один из первых примеров опциональной аннотации, не меняющей семантику программы, но предоставляющей компилятору дополнительные гарантии.

  • Комплексные и мнимые числа (<complex.h>) — формальная поддержка float _Complex, double _Imaginary, включая литералы (3.0 + 2.0*I). Введено в первую очередь для стандартизации научных библиотек (например, FFT), хотя в промышленном коде используется редко.

Несмотря на обширный список, внедрение C99 было неравномерным. Компиляторы Microsoft (MSVC) долгое время отказывались поддерживать VLA и restrict, ссылаясь на отсутствие спроса в Windows-разработке. GCC и Clang реализовали стандарт почти полностью, но многие крупные проекты (включая ядро Linux) по-прежнему придерживались подмножества C89 + избранных расширений, опасаясь потери переносимости. Это подтверждает тезис: влияние стандарта определяется не датой публикации, а степенью его принятия сообществом.

8. C11: адаптация к многопроцессорной эпохе

К 2011 году стало очевидно, что главный вызов для системного программирования — не абстракция, а параллелизм. Многоядерные процессоры стали массовыми, а отсутствие в языке средств для работы с потоками и синхронизацией вынуждало разработчиков полагаться на платформенно-зависимые API: POSIX threads (pthreads), Windows Threads, OpenMP. Это противоречило идее переносимости, заложенной в Си с самого начала.

Стандарт ISO/IEC 9899:2011 (C11) ввёл:

  • Многопоточность на уровне языка через заголовок <threads.h>: типы thrd_t, mtx_t, cnd_t, функции thrd_create, mtx_lock, cnd_wait. Однако реализация осталась опциональной: компилятор может не предоставлять <threads.h>, если целевая платформа не поддерживает потоки (например, bare-metal embedded). Это сохранило применимость Си в однопоточных средах.

  • Атомарные операции и модель памяти (<stdatomic.h>): типы _Atomic int, _Atomic(T), макросы atomic_load, atomic_store, memory_order_seq_cst и другие. Впервые в Си была формализована модель согласованности памяти, определяющая, в каком порядке наблюдаемы изменения, произведённые в разных потоках. Это позволило писать переносимый lock-free код — критически важно для высоконагруженных систем (например, ядра ОС, сетевые стеки).

  • Улучшенная поддержка Unicode:

    • типы char16_t, char32_t;
    • префиксы строк: u"...", U"...", u8"...";
    • заголовок <uchar.h> с функциями конвертации между UTF-8, UTF-16, UTF-32. Это не сделало Си «юникодовым языком» (в отличие от Swift или Rust), но предоставило минимальный набор для корректной обработки текста в международных приложениях.
  • Анонимные структуры и объединения:

    struct packet {
    int header;
    union { // без имени
    int payload_int;
    float payload_flt;
    };
    };
    // Доступ напрямую: pkt.payload_int = 42;

    Упрощает работу с вложенными данными без необходимости писать pkt.u.payload_int.

Кроме того, C11 официально декларировал VLA как опциональную функцию (реализация может их не поддерживать), что было признанием их спорной полезности в критически важных системах. Также был введён _Static_assert для проверки условий на этапе компиляции — аналог static_assert из C++11, но без зависимости от препроцессора.

C11 не стал «вторым ANSI C» — он не получил столь же повсеместного внедрения. Причина — в изменившейся экосистеме: к 2010-м годам Си перестал быть языком прикладной разработки и закрепился как язык системных компонентов. Приложения стали писать на C++, Java, C#, Go, а Си использовали для:

  • ядер ОС и драйверов;
  • встраиваемых систем (особенно с RTOS);
  • реализаций виртуальных машин и JIT-компиляторов;
  • высокооптимизированных библиотек (например, BLAS, OpenSSL, zlib).

В этих нишах требования к стабильности превалировали над желанием использовать новые возможности — особенно если они не поддерживались компиляторами для целевых архитектур (например, атомарные операции на 8-битных AVR).

9. C17 (C18): техническое обслуживание, а не развитие

Опубликованный в 2018 году стандарт ISO/IEC 9899:2018 (часто называемый C17, так как работа над ним завершилась в 2017-м) не вводил новых возможностей. Его цель — исправление ошибок и уточнение формулировок в C11. Всего было внесено около 100 технических исправлений (defect reports), например:

  • уточнение поведения _Alignas при выравнивании структур;
  • корректировка спецификации tmpfile в условиях ограниченной файловой системы;
  • исправления в описании атомарных операций для типов меньших, чем int.

C17 — это признание того, что Си достиг зрелости как инструмент, а не как объект активной языковой эволюции. Комитет X3J11 (ныне WG14) перешёл в режим технического сопровождения, подобно тому, как это произошло с Фортраном после Fortran 90. Это не означает «смерти» языка — напротив, это признак устоявшейся роли.


10. Си как лингвистическая ДНК: структурное наследие в последующих языках

Многие источники называют Си «отцом» C++, Java, JavaScript и других языков, но это утверждение требует уточнения. Синтаксическое сходство (операторы, блоки, if/while) — лишь внешний признак. Гораздо важнее то, как Си повлиял на модель исполнения, соглашения о двоичном интерфейсе и философию проектирования. Рассмотрим три уровня наследования.

10.1. Уровень ABI и системной интеграции

Любой язык, претендующий на применение в системном программировании, вынужден учитывать Application Binary Interface, доминирующий в ОС. На UNIX-подобных системах и Windows этот ABI — C ABI. Его черты:

  • соглашения о вызове функций (caller/callee responsibility for stack cleanup, passing arguments via stack or registers);
  • макет структур в памяти (order of fields, padding, alignment rules);
  • представление целых и указателей (endianness, width of void*);
  • модель связывания (linking): символы без манглинга, flat namespace.

Языки, желающие вызывать и быть вызванными из Си-библиотек (а это — подавляющее большинство системных API: POSIX, Win32, OpenGL, Vulkan, libc), вынуждены эмулировать C ABI. Например:

  • Rust предоставляет атрибут #[repr(C)], гарантирующий макет структуры, идентичный Си. Без него компилятор свободен менять порядок полей для оптимизации — но тогда структура несовместима с C API. Функции, экспортируемые в динамическую библиотеку, объявляются как extern "C" — это отключает манглинг имён и применяет C calling convention.

  • Go изначально не совместим с C ABI, но пакет cgo позволяет встраивать Си-код и вызывать Си-функции. При этом cgo генерирует промежуточный Си-файл, который компилируется тем же компилятором, что и основной Go-код, обеспечивая совместимость на уровне линковки. Более того, системные вызовы в Go реализованы через тонкие Си-обёртки (runtime·syscall), поскольку ядро ОС принимает вызовы только в C ABI.

  • Zig идёт дальше: он не имеет собственного рантайма. Программа на Zig, скомпилированная с -fno-stack-check, генерирует машинный код, эквивалентный коду, написанному на Си, включая инициализацию .data/.bss, обработку argc/argv, завершение через exit. Zig-код может ссылаться на глобальные переменные из Си и наоборот — без посредников.

Таким образом, Си определил не просто синтаксис, а стандарт взаимодействия программ с операционной системой и железом. Отказ от C ABI означает изоляцию от существующей инфраструктуры — что допустимо для прикладных языков (например, Java с JVM), но неприемлемо для системных.

10.2. Уровень модели памяти и управления ресурсами

Си ввёл явную, детерминированную, линейную модель памяти:

  • память — непрерывное адресное пространство;
  • объекты имеют чёткое время жизни (от входа в блок до выхода из него — для локальных, или от malloc до free — для динамических);
  • нет скрытых выделений, исключений, finalizer'ов.

Эта модель легла в основу языков, где предсказуемость и накладные расходы критичны:

  • C++ сохраняет её полностью: new/delete — прямые аналоги malloc/free, RAII — идиома поверх детерминированного вызова деструкторов, а не сборки мусора.
  • Rust отвергает неопределённое поведение Си, но сохраняет модель времени жизни. Заимствования (&T, &mut T) и правила ownership — это статические ограничения, накладываемые на ту же линейную память. unsafe блоки позволяют временно отключить проверки и работать, как в Си — включая прямые вызовы malloc.
  • Ada (до появления Си) имела свою модель памяти, но начиная с Ada 95 введена поддержка pragma Import (C, ...) и pragma Convention (C, ...), позволяющая напрямую использовать Си-библиотеки и структуры — с сохранением управления временем жизни на стороне вызывающего кода.

Даже языки с автоматической сборкой мусора вынуждены предоставлять escape-хэтчи в «мир Си»:

  • unsafe в C# (через fixed, Marshal.AllocHGlobal);
  • ctypes и cffi в Python;
  • Foreign Function Interface (FFI) в Haskell и Julia.

Это не дань традиции — это техническая необходимость: драйверы, криптографические примитивы, низкоуровневые сетевые стеки по-прежнему пишутся на Си (или в стиле Си), и любой язык должен уметь с ними взаимодействовать без копирования данных.

10.3. Уровень философии проектирования

Си сформулировал принцип, ставший неявным кредо системного программирования:

«Доверяй программисту. Не плати за то, чем не пользуешься.»

Этот принцип означает:

  • отсутствие принудительных проверок границ массивов;
  • отсутствие исключений (проверка ошибок — явная, через возвращаемые коды);
  • отсутствие виртуальной машины или JIT;
  • минимализм стандартной библиотеки (нет встроенных HTTP-клиентов, JSON-парсеров и т.п.).

Языки, следующие этой философии, даже при наличии современной типовой системы, остаются «духовными наследниками» Си:

  • Zig прямо заявляет: «No hidden control flow. No hidden allocations.» — и реализует это через обязательную обработку ошибок (!T), отсутствие макросов (только comptime), и встроенный менеджер зависимостей, оперирующий на уровне исходного кода, а не артефактов.
  • Rust, несмотря на сложный borrow checker, не вводит GC и не скрывает аллокации: Vec::with_capacity, Box::new, String::from_utf8_unchecked — всё явно, всё контролируемо.
  • V (Vlang) и Odin — языки, созданные как «Си с современным синтаксисом и безопасностью по умолчанию», но без отхода от модели прямой компиляции и линейной памяти.

Контрастен подход языков вроде Julia или MATLAB: там абстракции (многомерные массивы, автоматическое дифференцирование) встроены в язык и неотделимы от рантайма. Это эффективно для своей области, но исключает применение в ядре ОС или микроконтроллере. Си задал границу: язык не должен навязывать модель вычислений, он должен быть инструментом для построения таких моделей.


11. Критический анализ: цена контроля

Признание величия Си не должно заслонять его системных ограничений. Они не являются «недоработками», а следствием сознательных компромиссов, сделанных в 1970-х и сохранённых впоследствии.

11.1. Неопределённое поведение как архитектурная черта

В Си допускаются конструкции, поведение которых не определено стандартом:

  • разыменование нулевого указателя;
  • выход за границы массива;
  • использование неинициализированной переменной;
  • нарушение strict aliasing (чтение int через float*).

Это не ошибка стандарта — это инструмент оптимизации. Компилятор, встречая p[i], может предположить, что i находится в пределах [0, n), и убрать проверки. Если программист нарушил условие — поведение неопределено, и компилятор волен генерировать любой код (вплоть до UD2 на x86 или полного удаления ветки).

Результат:

  • высокая производительность «в среднем случае»;
  • экстремальная хрупкость при ошибках;
  • необходимость внешних инструментов: AddressSanitizer, UndefinedBehaviorSanitizer, статических анализаторов (Infer, Coverity), формальных верификаторов (Frama-C, KLEE).
11.2. Отсутствие абстракций безопасности

Си не предоставляет механизмов для предотвращения:

  • переполнения буфера (gets, strcpy без длины);
  • арифметического переполнения (INT_MAX + 1);
  • состояний гонки (data races);
  • двойного освобождения (double free).

Эти уязвимости составляют подавляющее большинство CVE в системном ПО. Однако их устранение в ядре языка противоречит философии: проверки замедляют код, а программист должен сам решать, где требуется безопасность (например, в сетевом парсере), а где — максимальная скорость (внутренний цикл ядра). Выход — в инструментах (ASan, MSan), библиотеках (OpenBSD strlcpy, reallocarray) и процессах (code review, fuzzing), а не в смене языка.

11.3. Неразрывная связь с конкретной архитектурой

Си предполагает:

  • плоское адресное пространство;
  • побайтовую адресуемость;
  • однородную память (нет разделения на код/данные с разными правами, как в Harvard-архитектуре).

Это создаёт трудности при портировании на:

  • микроконтроллеры с Harvard-архитектурой (AVR, PIC), где строки приходится размещать в .progmem с помощью PROGMEM;
  • системы с раздельной памятью (GPU, DSP), где требуется явное копирование через DMA;
  • платформы с не-8-битными байтами (исторически — CDC 6600 с 60-битными словами и 6-битными «байтами»).

Си не «непереносим» — он переносим в пределах фон Неймановской модели. Это ограничение, но не недостаток: язык не претендует на универсальность, а решает конкретный класс задач.


12. Перспективы: эволюция или замена?

Вопрос о «замене Си» некорректен. Си — не единый артефакт, а слой абстракции, вшитый в инфраструктуру вычислений. Заменить его можно только постепенно, заменяя компоненты, и только при наличии альтернативы, удовлетворяющей тем же условиям:

ТребованиеПочему критичноКандидаты и их ограничения
ПроизводительностьСравнимая с ручным ассемблеромRust (ближе всего), Zig — но с overhead'ом проверок в debug-режиме
ПереносимостьКомпиляция под 50+ архитектурRust поддерживает ~70 targets, но поддержка embedded отстаёт; Zig — активно развивается
ABI-совместимостьВызов/быть вызванным из libc, ядраТолько при явной эмуляции C ABI (Rust #[repr(C)], Zig extern "C")
Минимализм рантаймаОтсутствие GC, zero-cost exceptionsRust (да), Go (нет — требуется runtime для goroutines), Nim (условно — зависит от режима)
ИнструментарийОтладчики, профайлеры, статикаLLVM инфраструктура поддерживает Rust/Zig, но gdb/lldb требуют улучшения отладки ownership

Наиболее вероятный сценарий — постепенное вытеснение в новых проектах, но не в существующих:

  • Ядра ОС: Linux, Windows, FreeBSD останутся на Си ещё десятилетия. Но новые ядра (Redox на Rust, Hubris на Rust, Theseus на Rust/OCaml) демонстрируют альтернативу.
  • Встраиваемые системы: Си доминирует, но Rust растёт (Ferrocene — сертифицируемый Rust для safety-critical embedded).
  • Криптография и HPC: всё ещё Си (OpenSSL, BLIS), но Rust-альтернативы (Rustls, BLIS-rs) показывают сопоставимую производительность при меньшем числе CVE.

Окончательный вердикт:
Си не будет «заменён» — он будет впитан. Его модель памяти, ABI, философия контроля станут неотъемлемой частью следующих поколений языков, даже если синтаксис изменится. Как латынь перестала быть разговорным языком, но осталась основой медицины и права, так Си остаётся металл языков системного программирования — не видимый напрямую, но определяющий их структуру.